#!python3

# License: Apache 2.0. See LICENSE file in root directory.
# Copyright(c) 2020 Intel Corporation. All Rights Reserved.

#
# Syntax:
#     unit-test-config.py <dir> <build-dir>
#
# Looks for possible single-file unit-testing targets (test-*) in $dir, and builds
# a CMakeLists.txt in $builddir to compile them.
#
# Each target is compiled in its own project, so that each file ends up in a different
# process and so individual tests cannot affect others except through hardware.
#

import sys, os, subprocess, locale, re, getopt
from glob import glob

current_dir = os.path.dirname( os.path.abspath( __file__ ) )
sys.path.append( current_dir + os.sep + "py" )

from rspy import file, repo, libci, log

def usage():
    ourname = os.path.basename(sys.argv[0])
    print( 'Syntax: ' + ourname + ' [options] <dir> <build-dir>' )
    print( '        build unit-testing framework for the tree in $dir' )
    print( '        -r, --regex    configure all tests that fit the following regular expression' )
    print( '        -t, --tag      configure all tests with the following tag. If used multiple times runs all tests matching' )
    print( '                       all tags. e.g. -t tag1 -t tag2 will run tests who have both tag1 and tag2' )
    print( '                       tests automatically get tagged with \'exe\' or \'py\' and based on their location' )
    print( '                       inside unit-tests/, e.g. unit-tests/func/test-hdr.py gets [func, py]' )
    print( '        --list-tags    print out all available tags. This option will not run any tests' )
    print( '        --list-tests   print out all available tests. This option will not run any tests' )
    print( '                       if both list-tags and list-tests are specified each test will be printed along' )
    print( '                       with what tags it has' )
    print( '        --context      The context to use for test configuration' )
    print( '        --live         Only configure tests that are live (have test:device)' )
    print( '        --not-live     Only configure tests that are NOT live (do not have test:device)' )
    sys.exit(2)

regex = None
required_tags = []
list_tags = False
list_tests = False
context = None
live_only = False
not_live_only = False
# parse command-line:
try:
    opts, args = getopt.getopt( sys.argv[1:], 'hr:t:',
                                longopts=['help', 'regex=', 'tag=', 'list-tags', 'list-tests', 'context=', 'live', 'not-live'] )
except getopt.GetoptError as err:
    log.e( err )  # something like "option -a not recognized"
    usage()
for opt, arg in opts:
    if opt in ('-h', '--help'):
        usage()
    elif opt in ('-r', '--regex'):
        regex = arg
    elif opt in ('-t', '--tag'):
        required_tags.append( arg )
    elif opt == '--list-tags':
        list_tags = True
    elif opt == '--list-tests':
        list_tests = True
    elif opt == '--context':
        context = arg
    elif opt == '--live':
        if not_live_only:
            raise RuntimeError( '--live and --not-live are mutually exclusive' )
        live_only = True
    elif opt == '--not-live':
        if live_only:
            raise RuntimeError( '--live and --not-live are mutually exclusive' )
        not_live_only = True

if len( args ) != 2:
    usage()
dir=args[0]
builddir=args[1]
if not os.path.isdir( dir ) or not os.path.isdir( builddir ):
    usage()

# We have to stick to Unix conventions because CMake on Windows is fubar...
root = repo.root.replace( '\\' , '/' )
src = root + '/src'


def add_slash_before_spaces(links):
    """
    This function adds '\' char before spaces in string or list of strings.
    Because spaces in links can't been read properly from cmake files.
    """
    if links and type(links) is str:
        return links.replace( ' ', r'\ ' )
    if links and type(links) is list:
        # Build list comprehension of strings with backslash before spaces in case the link.
        # We can get a ${var} so, when we get this we return as is
        return [link.replace( ' ', r'\ ' ) if link[0] != '$' else link for link in links]
    else:
        raise TypeError


def generate_cmake( builddir, testdir, testname, filelist, custom_main, dependencies ):
    makefile = builddir + '/' + testdir + '/CMakeLists.txt'
    log.d( '   creating:', makefile )
    handle = open( makefile, 'w' )

    #filelist = add_slash_before_spaces(filelist)
    directory = add_slash_before_spaces(dir)
    root_directory = add_slash_before_spaces(root)

    filelist = '\n    '.join( filelist )
    handle.write( '''
# This file is automatically generated!!
# Do not modify or your changes will be lost!

cmake_minimum_required( VERSION 3.10.0 )
project( ''' + testname + ''' )

set( SRC_FILES ''' + filelist + '''
)
add_executable( ${PROJECT_NAME} ${SRC_FILES} )
add_definitions( ''' + ' '.join( f'-DLIBCI_DEPENDENCY_{d}' for d in dependencies.split() ) + ''' )
source_group( "Common Files" FILES ''' + directory + '''/test.cpp''')
    if not custom_main:
        handle.write(' ' + directory + '/unit-test-default-main.cpp')
    handle.write( ''' )
target_link_libraries( ${PROJECT_NAME} ''' + dependencies + ''' Catch2 )

set_target_properties( ${PROJECT_NAME} PROPERTIES FOLDER "Unit-Tests/''' + os.path.dirname( testdir ) + '''" )

using_easyloggingpp( ${PROJECT_NAME} SHARED )

# Add the repo root directory (so includes into src/ will be specific: <src/...>)
target_include_directories( ${PROJECT_NAME} PRIVATE ''' + root + ''')

''' )

    handle.close()


def find_include( include, relative_to ):
    """
    Try to match the include to an existing file.

    :param include: the text within "" or <> from the include directive
    :param relative_to: the directory from which to start finding if include is non-absolute
    :return: the normalized & absolute file path, if found -- otherwise, None
    """
    if include:
        if not os.path.isabs( include ):
            include = os.path.normpath( relative_to + '/' + include )
        include = include.replace( '\\', '/' )
        if os.path.exists( include ):
            return include


def find_include_in_dirs( include, dirs ):
    """
    Search for the given include in all the specified directories
    """
    for include_dir in dirs:
        path = find_include( include, include_dir )
        if path:
            return path


def find_includes( filepath, filelist, dependencies ):
    """
    Recursively searches a .cpp file for #include directives
    :param filelist: any previous includes already processed (pass an empty dict() if none)
    :param dependencies: set of dependencies
    :return: a dictionary (include->source) of includes found
    """
    include_dirs = list()
    if 'realsense2' in dependencies:
        include_dirs.append( os.path.join( root, 'include' ))
    include_dirs.append( os.path.join( root, 'third-party', 'rsutils', 'include' ))
    include_dirs.append( root )
    
    filedir = os.path.dirname(filepath)
    try:
        log.debug_indent()
        for include_line in file.grep( r'^\s*#\s*include\s+("(.*)"|<(.*)>)\s*$', filepath ):
            m = include_line['match']
            index = include_line['index']
            include = find_include( m.group(2), filedir ) or find_include_in_dirs( m.group(2), include_dirs ) or find_include_in_dirs( m.group(3), include_dirs )
            if include:
                if include in filelist:
                    log.d( m.group(0), '->', include, '(already processed)' )
                else:
                    log.d( m.group(0), '->', include )
                    filelist[include] = filepath
                    filelist = find_includes( include, filelist, dependencies )
            else:
                log.d( 'not found:', m.group(0) )
    finally:
        log.debug_unindent()
    return filelist

def process_cpp( dir, builddir ):
    global regex, required_tags, list_only, available_tags, tests_and_tags, live_only, not_live_only
    found = []
    shareds = []
    statics = []
    if regex:
        pattern = re.compile( regex )
    log.d( 'looking for C++ files in:', dir )
    for f in file.find( dir, r'(^|/)test-.*\.cpp$' ):
        testdir = os.path.splitext( f )[0]                          # "log/internal/test-all"  <-  "log/internal/test-all.cpp"
        testparent = os.path.dirname(testdir)                       # "log/internal"
        # We need the project name unique: keep the path but make it nicer:
        if testparent:
            testname = 'test-' + testparent.replace( '/', '-' ) + '-' + os.path.basename( testdir )[
                                                                        5:]  # "test-log-internal-all"
        else:
            testname = testdir  # no parent folder so we get "test-all"

        if regex and not pattern.search( testname ):
            continue

        log.d( '... found:', f )
        log.debug_indent()
        try:
            config = libci.TestConfigFromCpp( dir + os.sep + f, context )
            if required_tags or list_tags:
                if not all( tag in config.tags for tag in required_tags ):
                    continue
                available_tags.update( config.tags )
                if list_tests:
                    tests_and_tags[ testname ] = config.tags

            if testname not in tests_and_tags:
                tests_and_tags[testname] = None

            if live_only:
                if not config.configurations:
                    continue
            elif not_live_only:
                if config.configurations:
                    continue

            if config.donotrun:
                continue

            # Build the list of files we want in the project:
            # At a minimum, we have the original file, plus any common files
            filelist = [ dir + '/' + f ]
            includes = dict()
            # Add any files explicitly listed in the .cpp itself, like this:
            #         //#cmake:add-file <filename>
            # Any files listed are relative to $dir
            shared = False
            static = False
            custom_main = False
            dependencies = 'realsense2'
            for cmake_directive in file.grep( r'^//#cmake:\s*', dir + '/' + f ):
                m = cmake_directive['match']
                index = cmake_directive['index']
                cmd, *rest = cmake_directive['line'][m.end():].split()
                if cmd == 'add-file':
                    for additional_file in rest:
                        files = additional_file
                        if not os.path.isabs( additional_file ):
                            files = dir + '/' + testparent + '/' + additional_file
                        files = glob( files )
                        if not files:
                            log.e( f + '+' + str(index) + ': no files match "' + additional_file + '"' )
                        for abs_file in files:
                            abs_file = os.path.normpath( abs_file )
                            abs_file = abs_file.replace( '\\', '/' )
                            if not os.path.exists( abs_file ):
                                log.e( f + '+' + str(index) + ': file not found "' + additional_file + '"' )
                            log.d( 'add file:', abs_file )
                            filelist.append( abs_file )
                            if( os.path.splitext( abs_file )[0] == 'cpp' ):
                                # Add any "" includes specified in the .cpp that we can find
                                includes = find_includes( abs_file, includes, dependencies )
                elif cmd == 'static!':
                    if len(rest):
                        log.e( f"{f}+{index}: unexpected arguments past '{cmd}'" )
                    elif shared:
                        log.e( f"{f}+{index}: '{cmd}' mutually exclusive with 'shared!'" )
                    else:
                        log.d( 'static!' )
                        static = True
                elif cmd == 'shared!':
                    if len(rest):
                        log.e( f"{f}+{index}: unexpected arguments past '{cmd}'" )
                    elif static:
                        log.e( f"{f}+{index}: '{cmd}' mutually exclusive with 'static!'" )
                    else:
                        log.d( 'shared!' )
                        shared = True
                elif cmd == 'custom-main':
                    custom_main = True
                elif cmd == 'dependencies':
                    dependencies = ' '.join( rest )
                else:
                    log.e( f"{f}+{index}: unknown cmd '{cmd}' (should be 'add-file', 'static!', or 'shared!')" )

            # Add any includes specified in the .cpp that we can find
            includes = find_includes( dir + '/' + f, includes, dependencies )
            for include,source in includes.items():
                filelist.append( f'"{include}"  # {source}' )

            # all tests use the common test.cpp file
            filelist.append( root + "/unit-tests/test.cpp" )

            # 'cmake:custom-main' indicates that the test is defining its own main() function.
            # If not specified we use a default main() which lives in its own .cpp:
            if not custom_main:
                filelist.append( root + "/unit-tests/unit-test-default-main.cpp" )

            if list_only:
                continue

            # Each CMakeLists.txt sits in its own directory
            os.makedirs( builddir + '/' + testdir, exist_ok=True )  # "build/log/internal/test-all"
            generate_cmake( builddir, testdir, testname, filelist, custom_main, dependencies )
            if static:
                statics.append( testdir )
            elif shared:
                shareds.append( testdir )
            else:
                found.append( testdir )
        finally:
            log.debug_unindent()
    return found, shareds, statics
def process_py( dir, builddir ):
    # TODO
    return [],[],[]

list_only = list_tags or list_tests
available_tags = set()
tests_and_tags = dict()
normal_tests = []
shared_tests = []
static_tests = []
n,sh,st = process_cpp( dir, builddir )

if list_only:
    if list_tags and list_tests:
        for t in sorted( tests_and_tags.keys() ):
            print( t, "has tags:", ' '.join( tests_and_tags[t] ) )
    #
    elif list_tags:
        for t in sorted( list( available_tags ) ):
            print( t )
    #
    elif list_tests:
        for t in sorted( tests_and_tags.keys() ):
            print( t )
    sys.exit( 0 )

normal_tests.extend( n )
shared_tests.extend( sh )
static_tests.extend( st )
n,sh,st = process_py( dir, builddir )
normal_tests.extend( n )
shared_tests.extend( sh )
static_tests.extend( st )

cmakefile = builddir + '/CMakeLists.txt'
name = os.path.basename( os.path.realpath( dir ))
log.d( 'Creating "' + name + '" project in', cmakefile )

handle = open( cmakefile, 'w' )
handle.write( '''

''' )

n_tests = 0
for sdir in normal_tests:
    handle.write( 'add_subdirectory( ' + sdir + ' )\n' )
    log.d( '... including:', sdir )
    n_tests += 1
if len(shared_tests):
    handle.write( 'if(NOT ${BUILD_SHARED_LIBS})\n' )
    handle.write( '    message( INFO " ' + str(len(shared_tests)) + ' shared lib unit-tests will be skipped. Check BUILD_SHARED_LIBS to run them..." )\n' )
    handle.write( 'else()\n' )
    for test in shared_tests:
        handle.write( '    add_subdirectory( ' + test + ' )\n' )
        log.d( '... including:', sdir )
        n_tests += 1
    handle.write( 'endif()\n' )
if len(static_tests):
    handle.write( 'if(${BUILD_SHARED_LIBS})\n' )
    handle.write( '    message( INFO " ' + str(len(static_tests)) + ' static lib unit-tests will be skipped. Uncheck BUILD_SHARED_LIBS to run them..." )\n' )
    handle.write( 'else()\n' )
    for test in static_tests:
        handle.write( '    add_subdirectory( ' + test + ' )\n' )
        log.d( '... including:', sdir )
        n_tests += 1
    handle.write( 'endif()\n' )
handle.close()

print( 'Generated ' + str(n_tests) + ' unit-tests' )
if log.n_errors():
    sys.exit(1)
sys.exit(0)

